/*
Copyright 2011 Google Inc. All Rights Reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License
*/

goog.provide('testing.FrameRate');

goog.require('goog.events.EventTarget');

/**
 * A class that will trigger events when requestAnimationFrame events are called
 * in order to help run {goog.testing.AsyncTestCase} tests that are interested
 * in frame rate information.
 *
 * Example Usage:

 *   // Instantiate the {goog.testing.FrameRate} class:
 *   var frameRate = new FrameRate();
 *   var stableHandler = function(event) {
 *     console.log('frame rate stabilized with: ' + event.fps);
 *   }
 *   frameRate.addEventListener('stable', stableHandler);
 */
testing.FrameRate = function() {

  /**
   * @type {Array.<number>} The collection {Array.<number>} of fps samples
   * since the last call to {reset()}.
   */
  this.frameRateSamples = [];

  /**
   * @type {goog.events.EventTarget} to delegate to.
   * @private
   */
  this.eventTarget_ = new goog.events.EventTarget();

  /**
   * @type {number} of listeners of the 'stable' event. We track this here,
   * because we're performance testing and don't want to do the work to
   * determine stability on every frame if we don't have to. It's possible that
   * this work is impacting the ability of the frame rate to stabilize at all
   * on more performance limited environments....
   * @private
   */
  this.stableListenerCount_ = 0;

  this.setUpAnimationFrame();
}

/**
 * @define {number} Minimum frames per second to use when throwing a
 * 'minFpsThreshold' event. This should basically be the lowest frames per
 * second where an action should cause a test failure.
 */
testing.FrameRate.prototype.minFpsThreshold = 10;

/**
 * @define {number} The amount of variance (deviation) to allow as measured in
 * frames per second when triggering a 'stable' event. A browser at rest will
 * report some variance in minute readings, this is the adjustable amount of
 * variance that you deem acceptable.
 */
testing.FrameRate.prototype.stableDeviation = 4;

/**
 * @define {number} The lowest fps encountered since last begin(), or last call
 * to reset().
 */
testing.FrameRate.prototype.min = 0;

/**
 * @define {number} The highest fps encountered since the last begin(), or last
 * call to reset().
 */
testing.FrameRate.prototype.max = 0;

/**
 * @return {string} a label that is relevant to the current environment. If
 * {requestAnimationFrame} is not available, {setTimeout} will be used to
 * (poorly) simulate render frames, and in those cases, 'jps' will be returned
 * by this method. Otherwise, 'fps' will be returned.
 */
testing.FrameRate.prototype.getFpsLabel = function() {
  return testing.FrameRate.fpsLabel || '';
}

/**
 * @return {number} the most recently encountered frame rate. Which is
 * determined as follows:
 *
 *   Math.round(1000 / (milliseconds since last requestAnimationFrame));
 */
testing.FrameRate.prototype.getFps = function() {
  return this.fps;
}

/**
 * Figure out if any known variant of {requestAnimationFrame} is available in
 * this environment and associate window.requestAnimFrame with whichever is
 * found.
 *
 * If no version of {requestAnimationFrame} is found, fall back to {setTimout}
 * even though this really only measures "JavaScript calls Per Second" or "jps".
 *
 * NOTE: This method requires the global {window} object, so should only be
 * called in the presence of the DOM. While ugly, this requirement seemed
 * acceptable since that's currently the only place where we can find out about
 * render operations.
 */
testing.FrameRate.prototype.setUpAnimationFrame = function() {
  // TODO(lbayes): Figure out how to avoid junking
  // up window... Assigning this function to 'this',
  // causes invocation failures.
  if (!window.requestAnimFrame) {
    testing.FrameRate.fpsLabel = 'fps';
    window.requestAnimFrame =
      window['requestAnimationFrame']       ||
      window['webkitRequestAnimationFrame'] ||
      window['mozRequestAnimationFrame']    ||
      window['oRequestAnimationFrame']      ||
      window['msRequestAnimationFrame'];

    if (!window.requestAnimFrame) {
      testing.FrameRate.fpsLabel = 'jps';
      /**
       * Associate a handler with the generalized {requestAnimFrame}.
       * @param callback {function}
       * @param element {Element}
       */
      window.requestAnimFrame = function(callback, element) {
        window.setTimeout(callback, 1000 / 60);
      };
    }
  }
}

/**
 * Force the FrameRate to stop looping, should be called before object
 * destruction.
 */
testing.FrameRate.prototype.stop = function() {
  this.finishOnNextCall = true;
  this.looping = false;
}

/**
 * Begin looping on the requestAnimFrame.
 */
testing.FrameRate.prototype.begin = function() {
  if (!this.looping) {
    this.looping = true;
    this.continueLooping_();
  }
}

/**
 * Reset state values like max, min and lastTime.
 */
testing.FrameRate.prototype.reset = function() {
  this.lastTime = null;
  this.max = 0;
  this.min = 0;
  this.lastFrameSamples = this.frameRateSamples;
  this.frameRateSamples = [];
}

/**
 * Continue looping on requestAnimFrame and update the frame rate with each
 * loop.
 * @private
 */
testing.FrameRate.prototype.continueLooping_ = function() {
  if (this.finishOnNextCall) {
    this.finishOnNextCall = false;
    return;
  }
  if (this.looping) {
    this.updateFps_();
    var self = this;
    window.requestAnimFrame(function() {
      self.continueLooping_();
    });
  }
}

/**
 * Update the fps, min and max.
 * @private
 */
testing.FrameRate.prototype.updateFps_ = function() {
  if (this.lastTime) {
    var now = new Date().getTime();
    var millisBetweenCalls = now - this.lastTime;
    this.lastTime = now;
    //console.log('millis between: ' + millisBetweenCalls);
    if (millisBetweenCalls > 1) {
      this.fps = Math.round(1000 / millisBetweenCalls);
      this.min = Math.min(this.min || 10000, this.fps);
      this.max = Math.max(this.max, this.fps);
      this.frameRateSamples.push(this.fps);
      this.dispatchStableIfNecessary_();
    }
  } else {
    this.lastTime = new Date().getTime();
  }
}

/**
 * Dispatch stable event if we need to.
 * @private
 */
testing.FrameRate.prototype.dispatchStableIfNecessary_ = function() {
  if (this.hasStableListeners_() && this.frameRateIsStatisticallyStable_()) {
    this.eventTarget_.dispatchEvent({ type: 'stable', fps: this.fps });
  }
}

/**
 * @return {boolean} true if there are any subscribers to the 'stable' event.
 * @private
 */
testing.FrameRate.prototype.hasStableListeners_ = function() {
  return this.stableListenerCount_ > 0;
}

/**
 * @return {boolean} true if the frame rate is statistically stable.
 * @private
 */
testing.FrameRate.prototype.frameRateIsStatisticallyStable_ = function() {
  if (this.frameRateSamples.length < 15) {
    return false;
  }

  var samples = this.frameRateSamples.slice(-15);
  var meanFrameRate = this.getAverageFrameRate(samples);
  var standardDeviation = this.getStandardDeviation_(samples, meanFrameRate);

  // if stable deviation is greater than standard deviation multiplied by two
  // then 95% of our samples are within our stable deviation range
  //if (this.stableDeviation > (standardDeviation * 3)) { // 99.7%
  //if (this.stableDeviation > (standardDeviation * 2)) { // 95%
  if (this.stableDeviation > (standardDeviation)) {
    return true;
  }
  return false;
}

/**
 * @return {number} the standard deviation based on the provided samples and
 * mean.
 * @param {Array.<number>} samples to iterate over.
 * @param {number} mean of provided samples.
 */
testing.FrameRate.prototype.getStandardDeviation_ = function(samples,
    mean) {
  var variance = 0;

  samples.forEach(function(sample, index) {
    variance += Math.pow(sample - mean, 2);
  });

  variance /= (samples.length - 1);

  return Math.sqrt(variance);
}

/**
 * @return {number} true if the frame rate is statistically 'stable'.
 */
testing.FrameRate.prototype.frameRateIsStable_ = function() {
  // We need 'enough' samples to find out if we're stable:
  if (this.frameRateSamples.length < 15) {
    return false;
  }

  // pop the last 15 (ideally 1/2 second) of fps samples
  var samples = this.frameRateSamples.slice(-15);
  var averageFrameRate = this.getAverageFrameRate(samples);
  var sample = null;
  var len = samples.length;
  for (var i = 0; i < len; i++) {
    sample = samples[i];
    if (Math.abs(sample - averageFrameRate) > this.stableDeviation) {
      return false;
    }
  }
  return true;

}

/**
 * @return {boolean} true if any frame in the current collection of samples is
 * below the {minFpsThreshold}.
 */
testing.FrameRate.prototype.isAnyFrameBelowThreshold = function() {
  var samples = this.frameRateSamples;
  var len = samples.length;
  for (var i = 0; i < len; i++) {
    if (samples[i] < this.minFpsThreshold) {
      return true;
    }
  }
  return false;
}

/**
 * @return {number} the average frame rate, based on the current collection of
 * samples.
 */
testing.FrameRate.prototype.getAverageFrameRate = function(samples) {
  if (samples == null) {
    samples = this.frameRateSamples;
  }
  if (samples == null || samples.length == 0) {
    return 0;
  }
  var len = samples.length;
  var sum = 0;
  for (var i = 0; i < len; i++) {
    sum += samples[i];
  }

  return Math.round(sum / len);
}

/**
 * @override
 */
testing.FrameRate.prototype.addEventListener = function(type, handler) {
  if (type == 'stable') {
    this.stableListenerCount_++;
  }
  this.begin();
  this.eventTarget_.addEventListener(type, handler);
}

/**
 * @override
 */
testing.FrameRate.prototype.removeEventListener = function(type,
    handler) {
  if (type == 'stable') {
    this.stableListenerCount_ = Math.max(0, --this.stableListenerCount_);
  }

  this.eventTarget_.removeEventListener(type, handler);
}
